ποΈGitΠ―ΡΠ°ποΈ
Commit a09e1368f41afcff3fa8a6c1bca7906301a0521b
Parents : 76847dd
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Date : 2026-06-05T05:15:11-05:00
perf: add Baseline Profile generation for :androidApp (#5735)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changes
8 files changed, 310 insertions(+), 3 deletions(-)
Diff
diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml
index 63e1b9f5b5..7588cf886c 100644
--- a/.github/workflows/scheduled-updates.yml
+++ b/.github/workflows/scheduled-updates.yml
@@ -2,7 +2,7 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
on:
schedule:
- - cron: '0 */4 * * *' # Run every 4 hours (was hourly β reduced to cut cascade CI cost)
+ - cron: '0 */6 * * *' # Run every 6 hours (raised from 4h to absorb the added baseline-profile step)
workflow_dispatch: # Allow manual triggering
jobs:
@@ -111,6 +111,45 @@ jobs:
run: ./gradlew graphUpdate
continue-on-error: true
+ # ββ Baseline Profile regeneration βββββββββββββββββββββββββββββββββββ
+ # Runs on every scheduled tick (and manual dispatch). Generation needs a booted emulator
+ # (~10 min); continue-on-error keeps flakiness from blocking the firmware/translation PR.
+ - name: Enable KVM (for the emulator)
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
+ | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Generate Baseline Profile
+ id: generate_baseline
+ continue-on-error: true # Emulator flakiness must not block the firmware/translation PR.
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 34
+ target: google_apis # google flavor needs GMS (Maps) on the device image
+ arch: x86_64
+ profile: pixel_6
+ disable-animations: true
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ # Writes androidApp/src/google/generated/baselineProfiles/ via the androidx.baselineprofile plugin.
+ script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true
+
+ - name: Detect baseline profile changes
+ id: baseline
+ run: |
+ profile_dir="androidApp/src/google/generated/baselineProfiles"
+ outcome="${{ steps.generate_baseline.outcome }}"
+ if [ "$outcome" = "skipped" ]; then
+ echo "status=skipped" >> "$GITHUB_OUTPUT"
+ elif [ "$outcome" != "success" ]; then
+ echo "::warning::Baseline profile generation failed (outcome: $outcome). Skipping."
+ echo "status=error" >> "$GITHUB_OUTPUT"
+ elif [ -n "$(git status --porcelain "$profile_dir" 2>/dev/null)" ]; then
+ echo "status=updated" >> "$GITHUB_OUTPUT"
+ else
+ echo "status=unchanged" >> "$GITHUB_OUTPUT"
+ fi
- name: Build PR body
id: pr_body
@@ -119,6 +158,7 @@ jobs:
firmware_detail="${{ steps.firmware.outputs.detail }}"
hardware_status="${{ steps.hardware.outputs.status }}"
hardware_detail="${{ steps.hardware.outputs.detail }}"
+ baseline_status="${{ steps.baseline.outputs.status }}"
body="This PR includes automated updates from the scheduled workflow:"
body+=$'\n'
@@ -139,6 +179,15 @@ jobs:
*) body+=$'\n'"- β \`device_hardware.json\` β unknown status." ;;
esac
+ # Baseline profile (daily / manual only)
+ case "$baseline_status" in
+ updated) body+=$'\n'"- β
\`androidApp\` baseline profile regenerated on an emulator." ;;
+ unchanged) body+=$'\n'"- βοΈ \`androidApp\` baseline profile regenerated β no changes detected." ;;
+ error) body+=$'\n'"- β οΈ \`androidApp\` baseline profile generation failed β skipped (see workflow logs)." ;;
+ skipped) ;; # Not a daily/manual run β omit the line entirely.
+ *) ;;
+ esac
+
# Crowdin & graphs (always attempted)
body+=$'\n'"- Source strings were uploaded to Crowdin."
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
@@ -158,7 +207,7 @@ jobs:
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
commit-message: |
- chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)
+ chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)
Automated updates for:
- Firmware releases list
@@ -166,7 +215,8 @@ jobs:
- Crowdin source string uploads
- Crowdin translation downloads
- Module dependency graphs
- title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)'
+ - androidApp baseline profile
+ title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs, Baseline)'
body: ${{ steps.pr_body.outputs.content }}
branch: 'scheduled-updates'
base: 'main'
@@ -174,6 +224,7 @@ jobs:
add-paths: |
androidApp/src/main/assets/firmware_releases.json
androidApp/src/main/assets/device_hardware.json
+ androidApp/src/google/generated/baselineProfiles/**
fastlane/metadata/android/**
**/strings.xml
**/README.md
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 724353be77..6de4175948 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -30,6 +30,7 @@ plugins {
id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.secrets)
+ alias(libs.plugins.androidx.baselineprofile)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
alias(libs.plugins.devtools.ksp)
@@ -254,6 +255,9 @@ dependencies {
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
+ // Installs the baseline profile produced by :baselineprofile at app startup (API < 31)
+ // and lets ART honor it on first launch. On API 31+ the platform installs it automatically.
+ implementation(libs.androidx.profileinstaller)
implementation(libs.kotlinx.serialization.json)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
@@ -308,4 +312,8 @@ dependencies {
testImplementation(libs.androidx.glance.appwidget)
// JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2")
+
+ // Producer of the baseline profile consumed by the release build. The androidx.baselineprofile
+ // plugin merges the generated rules into src/<variant>/generated/baselineProfiles at build time.
+ baselineProfile(projects.baselineprofile)
}
diff --git a/baselineprofile/README.md b/baselineprofile/README.md
new file mode 100644
index 0000000000..ca3ec581eb
--- /dev/null
+++ b/baselineprofile/README.md
@@ -0,0 +1,32 @@
+# `:baselineprofile`
+
+Generates a [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview)
+for `:androidApp` β AOT-compiling the cold-start and first-frame code paths so ART doesn't pay the
+JIT cost on first launch. Targets the **google** flavor (the variant most users run).
+
+## Generate the profile (run on a device/emulator)
+
+```bash
+./gradlew :androidApp:generateGoogleReleaseBaselineProfile
+```
+
+Output is merged into `androidApp/src/google/generated/baselineProfiles/baseline-prof.txt`.
+**Commit that file** β release builds package it via `androidx.profileinstaller`.
+
+## Quantify the win
+
+```bash
+./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
+```
+
+Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output.
+
+## Scope / TODO
+
+- The journey (`BaselineProfileGenerator`) is cold-start only, since CI has no paired radio.
+ Extend it with post-connection screens (node list, map, message thread) once a fake transport or
+ connected device is wired into the harness β a more representative journey yields a better profile.
+- For hermetic CI generation, swap `useConnectedDevices = true` in `build.gradle.kts` for a
+ [Gradle Managed Device](https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile#gradle-managed).
+- f-droid currently inherits no profile (only `google` is produced). Add a second flavor here if
+ the f-droid startup path ever diverges enough to matter.
diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts
new file mode 100644
index 0000000000..c2cb86d866
--- /dev/null
+++ b/baselineprofile/build.gradle.kts
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.test)
+ alias(libs.plugins.androidx.baselineprofile)
+}
+
+android {
+ namespace = "org.meshtastic.baselineprofile"
+ compileSdk = 37
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ defaultConfig {
+ // Macrobenchmark / BaselineProfileRule require API 28+ on the test (device) side.
+ // The generated profile is still installed on the app's real minSdk (26) via profileinstaller.
+ minSdk = 28
+ targetSdk = 37
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ // App module whose startup we profile/benchmark.
+ targetProjectPath = ":androidApp"
+
+ // The app declares a `marketplace` flavor dimension (google / fdroid). A test module must
+ // match it. We pin to `google` β the variant the vast majority of users run (and the one with
+ // Maps). f-droid can reuse the same profile; wire a second flavor here if it ever diverges.
+ flavorDimensions += "marketplace"
+ productFlavors { create("google") { dimension = "marketplace" } }
+}
+
+kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
+
+baselineProfile {
+ // Generate on an attached device/emulator. For hermetic CI, replace with a Gradle Managed
+ // Device (see README.md) and set managedDevices + useConnectedDevices = false.
+ useConnectedDevices = true
+}
+
+dependencies {
+ implementation(libs.androidx.test.ext.junit)
+ implementation(libs.androidx.test.espresso.core)
+ implementation(libs.androidx.uiautomator)
+ implementation(libs.androidx.benchmark.macro.junit4)
+}
diff --git a/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt
new file mode 100644
index 0000000000..7928374083
--- /dev/null
+++ b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/BaselineProfileGenerator.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.baselineprofile
+
+import androidx.benchmark.macro.junit4.BaselineProfileRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Generates a Baseline Profile for the app's critical user journey.
+ *
+ * Run it with:
+ * ```
+ * ./gradlew :androidApp:generateGoogleReleaseBaselineProfile
+ * ```
+ *
+ * The [androidx.baselineprofile] plugin on `:androidApp` drives this against the auto-created
+ * `nonMinifiedRelease` variant and merges the result into
+ * `androidApp/src/google/generated/baselineProfiles/`. Commit that output so release builds ship it.
+ *
+ * The journey is intentionally minimal (cold start β first frame) because CI has no paired radio.
+ * Extend it with post-connection screens (node list, map, message thread) once a fake transport or
+ * connected device is available in the harness β the more representative the journey, the better the
+ * profile.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class BaselineProfileGenerator {
+
+ @get:Rule val baselineProfileRule = BaselineProfileRule()
+
+ @Test
+ fun generate() =
+ baselineProfileRule.collect(
+ // The plugin injects the target applicationId (handles the google debug/release suffix).
+ packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
+ // Also produce a startup profile (dexlayout hints) for faster cold start, not just AOT rules.
+ includeInStartupProfile = true,
+ ) {
+ pressHome()
+ startActivityAndWait()
+ device.waitForIdle()
+ }
+
+ private companion object {
+ const val DEFAULT_APP_ID = "com.geeksville.mesh"
+ }
+}
diff --git a/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt
new file mode 100644
index 0000000000..b2f3bf43bb
--- /dev/null
+++ b/baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/StartupBenchmark.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.baselineprofile
+
+import androidx.benchmark.macro.BaselineProfileMode
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Measures cold-start time with and without the Baseline Profile so the win is quantifiable.
+ *
+ * Run it with:
+ * ```
+ * ./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
+ * ```
+ *
+ * Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output: the delta
+ * is the startup improvement the shipped profile buys. `Partial(Require)` fails loudly if the
+ * profile is missing, so this also guards against a release that silently dropped it.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class StartupBenchmark {
+
+ @get:Rule val benchmarkRule = MacrobenchmarkRule()
+
+ @Test fun startupCompilationNone() = startup(CompilationMode.None())
+
+ @Test
+ fun startupCompilationBaselineProfiles() =
+ startup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
+
+ private fun startup(compilationMode: CompilationMode) =
+ benchmarkRule.measureRepeated(
+ packageName = InstrumentationRegistry.getArguments().getString("targetAppId") ?: DEFAULT_APP_ID,
+ metrics = listOf(StartupTimingMetric()),
+ compilationMode = compilationMode,
+ startupMode = StartupMode.COLD,
+ iterations = 10,
+ ) {
+ pressHome()
+ startActivityAndWait()
+ }
+
+ private companion object {
+ const val DEFAULT_APP_ID = "com.geeksville.mesh"
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8a09a25158..d929ecb620 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -37,6 +37,14 @@ turbine = "1.2.1"
# Compose Screenshot Testing
compose-screenshot = "0.0.1-alpha15"
+# Baseline Profiles / Macrobenchmark
+# `benchmark` drives both the androidx.benchmark macro lib AND the androidx.baselineprofile
+# Gradle plugin (same coordinates/version). Kept on the alpha track to stay compatible with
+# AGP 9.x (the stable 1.4.x line predates AGP 9 support).
+benchmark = "1.5.0-alpha06"
+profileinstaller = "1.4.1"
+androidx-uiautomator = "2.3.0"
+
# Compose Multiplatform
compose-multiplatform = "1.11.1"
compose-multiplatform-material3 = "1.11.0-alpha07"
@@ -148,6 +156,11 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version =
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" }
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" }
+# Baseline Profiles / Macrobenchmark
+androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" }
+androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
+androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
+
# AndroidX Compose (explicit versions β BOM removed; CMP is the sole version authority)
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity)
@@ -303,6 +316,8 @@ meshtastic-protobufs = { module = "org.meshtastic:protobufs", version.ref = "mes
# Android
android-application = { id = "com.android.application", version.ref = "agp" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+android-test = { id = "com.android.test" }
+androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "compose-screenshot" }
# Jetbrains
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 70aa14a209..1893844ea7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -133,4 +133,5 @@ include(
":core:barcode",
":feature:widget",
":screenshot-tests",
+ ":baselineprofile",
)
Served by rngit 1.5.0 - Generated in 0.1s